[新增] 用 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
|
package io.legado.app.ui.main.rss
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.transition.TransitionManager
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.SubMenu
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.appcompat.widget.SearchView
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.isVisible
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
import io.legado.app.base.VMBaseFragment
|
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.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.lib.dialogs.alert
|
||||||
|
import io.legado.app.ui.login.SourceLoginActivity
|
||||||
import io.legado.app.ui.main.MainFragmentInterface
|
import io.legado.app.ui.main.MainFragmentInterface
|
||||||
import io.legado.app.ui.rss.article.RssSortActivity
|
import io.legado.app.ui.rss.article.RssSortActivity
|
||||||
import io.legado.app.ui.rss.favorites.RssFavoritesActivity
|
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.edit.RssSourceEditActivity
|
||||||
import io.legado.app.ui.rss.source.manage.RssSourceActivity
|
import io.legado.app.ui.rss.source.manage.RssSourceActivity
|
||||||
import io.legado.app.ui.rss.subscription.RuleSubActivity
|
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.openUrl
|
||||||
import io.legado.app.utils.startActivity
|
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),
|
class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
|
||||||
MainFragmentInterface,
|
MainFragmentInterface {
|
||||||
RssAdapter.CallBack {
|
|
||||||
|
|
||||||
constructor(position: Int) : this() {
|
constructor(position: Int) : this() {
|
||||||
val bundle = Bundle()
|
val bundle = Bundle()
|
||||||
@@ -56,140 +36,36 @@ class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
|
|||||||
|
|
||||||
override val position: Int? get() = arguments?.getInt("position")
|
override val position: Int? get() = arguments?.getInt("position")
|
||||||
|
|
||||||
private val binding by viewBinding(FragmentRssBinding::bind)
|
|
||||||
override val viewModel by viewModels<RssViewModel>()
|
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?) {
|
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
setSupportToolbar(binding.topBar)
|
// Compose handles UI
|
||||||
initSearchView()
|
|
||||||
initRecyclerView()
|
|
||||||
initGroupData()
|
|
||||||
upRssFlowJob()
|
|
||||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
|
|
||||||
binding.appBar.fitsSystemWindows = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCompatCreateOptionsMenu(menu: Menu) {
|
private fun openRss(rssSource: RssSource) {
|
||||||
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) {
|
|
||||||
if (rssSource.singleUrl) {
|
if (rssSource.singleUrl) {
|
||||||
viewModel.getSingleUrl(rssSource) { url ->
|
viewModel.getSingleUrl(rssSource) { url ->
|
||||||
if (url.startsWith("http", true)) {
|
if (url.startsWith("http", true)) {
|
||||||
@@ -208,17 +84,20 @@ class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toTop(rssSource: RssSource) {
|
private fun edit(rssSource: RssSource) {
|
||||||
viewModel.topSource(rssSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun edit(rssSource: RssSource) {
|
|
||||||
startActivity<RssSourceEditActivity> {
|
startActivity<RssSourceEditActivity> {
|
||||||
putExtra("sourceUrl", rssSource.sourceUrl)
|
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) {
|
alert(R.string.draw) {
|
||||||
setMessage(getString(R.string.sure_del) + "\n" + rssSource.sourceName)
|
setMessage(getString(R.string.sure_del) + "\n" + rssSource.sourceName)
|
||||||
noButton()
|
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
|
package io.legado.app.ui.main.rss
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.script.rhino.runScriptWithContext
|
import com.script.rhino.runScriptWithContext
|
||||||
import io.legado.app.base.BaseViewModel
|
import io.legado.app.base.BaseViewModel
|
||||||
import io.legado.app.data.appDb
|
import io.legado.app.data.appDb
|
||||||
import io.legado.app.data.entities.RssSource
|
import io.legado.app.data.entities.RssSource
|
||||||
import io.legado.app.help.source.SourceHelp
|
import io.legado.app.help.source.SourceHelp
|
||||||
import io.legado.app.utils.toastOnUi
|
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) {
|
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) {
|
fun topSource(vararg sources: RssSource) {
|
||||||
execute {
|
execute {
|
||||||
sources.sortBy { it.customOrder }
|
sources.sortBy { it.customOrder }
|
||||||
@@ -78,6 +146,4 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
|
|||||||
context.toastOnUi(it.localizedMessage)
|
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
|
package io.legado.app.ui.rss.favorites
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import androidx.compose.runtime.Composable
|
||||||
import android.view.MenuItem
|
import io.legado.app.base.BaseComposeActivity
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收藏夹
|
* 收藏夹
|
||||||
*/
|
*/
|
||||||
class RssFavoritesActivity : BaseActivity<ActivityRssFavoritesBinding>() {
|
class RssFavoritesActivity : BaseComposeActivity() {
|
||||||
|
|
||||||
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 = ""
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
initView()
|
|
||||||
upFragments()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
@Composable
|
||||||
super.onResume()
|
override fun Content() {
|
||||||
//从ReadRssActivity退出时,判断是否需要重新定位tabLayout选中项
|
RssFavoritesScreen(
|
||||||
if (currentGroup.isNotEmpty() && groupList.isNotEmpty()){
|
onBackClick = { finish() }
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package io.legado.app.ui.rss.favorites
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.base.BaseViewModel
|
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) {
|
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
|
package io.legado.app.ui.rss.source.manage
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import androidx.compose.runtime.Composable
|
||||||
import android.os.Bundle
|
import io.legado.app.base.BaseComposeActivity
|
||||||
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 io.legado.app.ui.rss.source.edit.RssSourceEditActivity
|
import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
|
||||||
import io.legado.app.ui.widget.SelectActionBar
|
import io.legado.app.ui.theme.AppTheme
|
||||||
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.utils.startActivity
|
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 : BaseComposeActivity() {
|
||||||
* 订阅源管理
|
|
||||||
*/
|
|
||||||
class RssSourceActivity : VMBaseActivity<ActivityRssSourceBinding, RssSourceViewModel>(),
|
|
||||||
PopupMenu.OnMenuItemClickListener,
|
|
||||||
SelectActionBar.CallBack,
|
|
||||||
RssSourceAdapter.CallBack {
|
|
||||||
|
|
||||||
override val binding by viewBinding(ActivityRssSourceBinding::inflate)
|
@Composable
|
||||||
override val viewModel by viewModels<RssSourceViewModel>()
|
override fun Content() {
|
||||||
private val importRecordKey = "rssSourceRecordKey"
|
AppTheme {
|
||||||
private val adapter by lazy { RssSourceAdapter(this, this) }
|
RssSourceScreen(
|
||||||
private val searchView: SearchView by lazy {
|
onBackClick = { finish() },
|
||||||
binding.titleBar.findViewById(R.id.search_view)
|
onEditSource = { source ->
|
||||||
}
|
startActivity<RssSourceEditActivity> {
|
||||||
private var sourceFlowJob: Job? = null
|
putExtra("sourceUrl", source.sourceUrl)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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
|
package io.legado.app.ui.rss.source.manage
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.text.TextUtils
|
import androidx.compose.runtime.Immutable
|
||||||
import io.legado.app.base.BaseViewModel
|
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.appDb
|
||||||
import io.legado.app.data.entities.RssSource
|
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.DefaultData
|
||||||
import io.legado.app.help.source.SourceHelp
|
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.FileUtils
|
||||||
import io.legado.app.utils.GSON
|
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.stackTraceStr
|
||||||
import io.legado.app.utils.toastOnUi
|
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
|
import java.io.File
|
||||||
|
|
||||||
/**
|
@Immutable
|
||||||
* 订阅源管理数据修改
|
data class RssSourceItemUi(
|
||||||
* 修改数据要copy,直接修改会导致界面不刷新
|
override val id: String,
|
||||||
*/
|
val name: String,
|
||||||
class RssSourceViewModel(application: Application) : BaseViewModel(application) {
|
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) {
|
fun topSource(vararg sources: RssSource) {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
sources.sortBy { it.customOrder }
|
val minOrder = dao.minOrder - 1
|
||||||
val minOrder = appDb.rssSourceDao.minOrder - 1
|
val updated = sources.sortedBy { it.customOrder }.mapIndexed { index, source ->
|
||||||
val array = Array(sources.size) {
|
source.copy(customOrder = minOrder - index)
|
||||||
sources[it].copy(customOrder = minOrder - it)
|
|
||||||
}
|
}
|
||||||
appDb.rssSourceDao.update(*array)
|
dao.update(*updated.toTypedArray())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bottomSource(vararg sources: RssSource) {
|
fun bottomSource(vararg sources: RssSource) {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
sources.sortBy { it.customOrder }
|
val maxOrder = dao.maxOrder + 1
|
||||||
val maxOrder = appDb.rssSourceDao.maxOrder + 1
|
val updated = sources.sortedBy { it.customOrder }.mapIndexed { index, source ->
|
||||||
val array = Array(sources.size) {
|
source.copy(customOrder = maxOrder + index)
|
||||||
sources[it].copy(customOrder = maxOrder + it)
|
|
||||||
}
|
}
|
||||||
appDb.rssSourceDao.update(*array)
|
dao.update(*updated.toTypedArray())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun del(vararg rssSource: RssSource) {
|
fun del(vararg rssSource: RssSource) {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
SourceHelp.deleteRssSources(rssSource.toList())
|
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) {
|
fun update(vararg rssSource: RssSource) {
|
||||||
execute { appDb.rssSourceDao.update(*rssSource) }
|
viewModelScope.launch(Dispatchers.IO) { dao.update(*rssSource) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun upOrder() {
|
fun upOrder() {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val sources = appDb.rssSourceDao.all
|
val sources = dao.all
|
||||||
for ((index: Int, source: RssSource) in sources.withIndex()) {
|
for ((index: Int, source: RssSource) in sources.withIndex()) {
|
||||||
source.customOrder = index + 1
|
source.customOrder = index + 1
|
||||||
}
|
}
|
||||||
appDb.rssSourceDao.update(*sources.toTypedArray())
|
dao.update(*sources.toTypedArray())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enableSelection(sources: List<RssSource>) {
|
fun enableSelectionByIds(ids: Set<String>) {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val array = Array(sources.size) {
|
val sources = dao.getRssSources(*ids.toTypedArray())
|
||||||
sources[it].copy(enabled = true)
|
val updated = sources.map { it.copy(enabled = true) }
|
||||||
}
|
dao.update(*updated.toTypedArray())
|
||||||
appDb.rssSourceDao.update(*array)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disableSelection(sources: List<RssSource>) {
|
fun disableSelectionByIds(ids: Set<String>) {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val array = Array(sources.size) {
|
val sources = dao.getRssSources(*ids.toTypedArray())
|
||||||
sources[it].copy(enabled = false)
|
val updated = sources.map { it.copy(enabled = false) }
|
||||||
}
|
dao.update(*updated.toTypedArray())
|
||||||
appDb.rssSourceDao.update(*array)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,75 +292,64 @@ class RssSourceViewModel(application: Application) : BaseViewModel(application)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectionAddToGroups(sources: List<RssSource>, groups: String) {
|
fun selectionAddToGroups(ids: Set<String>, groups: String) {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val array = Array(sources.size) {
|
val sources = dao.getRssSources(*ids.toTypedArray())
|
||||||
sources[it].copy().addGroup(groups)
|
val updated = sources.map { it.copy().addGroup(groups) }
|
||||||
}
|
dao.update(*updated.toTypedArray())
|
||||||
appDb.rssSourceDao.update(*array)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectionRemoveFromGroups(sources: List<RssSource>, groups: String) {
|
fun selectionRemoveFromGroups(ids: Set<String>, groups: String) {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val array = Array(sources.size) {
|
val sources = dao.getRssSources(*ids.toTypedArray())
|
||||||
sources[it].copy().removeGroup(groups)
|
val updated = sources.map { it.copy().removeGroup(groups) }
|
||||||
}
|
dao.update(*updated.toTypedArray())
|
||||||
appDb.rssSourceDao.update(*array)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addGroup(group: String) {
|
fun upGroup(oldGroup: String, newGroup: String) {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val sources = appDb.rssSourceDao.noGroup
|
val sources = dao.getByGroup(oldGroup)
|
||||||
sources.forEach { source ->
|
sources.forEach { source ->
|
||||||
source.sourceGroup = group
|
source.sourceGroup?.split(",")?.toHashSet()?.let {
|
||||||
}
|
|
||||||
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 {
|
|
||||||
it.remove(oldGroup)
|
it.remove(oldGroup)
|
||||||
if (!newGroup.isNullOrEmpty())
|
if (newGroup.isNotEmpty()) it.add(newGroup)
|
||||||
it.add(newGroup)
|
source.sourceGroup = it.joinToString(",")
|
||||||
source.sourceGroup = TextUtils.join(",", it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
appDb.rssSourceDao.update(*sources.toTypedArray())
|
dao.update(*sources.toTypedArray())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delGroup(group: String) {
|
fun delGroup(group: String) {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
execute {
|
val sources = dao.getByGroup(group)
|
||||||
val sources = appDb.rssSourceDao.getByGroup(group)
|
sources.forEach { source ->
|
||||||
sources.forEach { source ->
|
source.sourceGroup?.split(",")?.toHashSet()?.let {
|
||||||
source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let {
|
it.remove(group)
|
||||||
it.remove(group)
|
source.sourceGroup = it.joinToString(",")
|
||||||
source.sourceGroup = TextUtils.join(",", it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
appDb.rssSourceDao.update(*sources.toTypedArray())
|
|
||||||
}
|
}
|
||||||
|
dao.update(*sources.toTypedArray())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun importDefault() {
|
fun importDefault() {
|
||||||
execute {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
DefaultData.importDefaultRssSources()
|
DefaultData.importDefaultRssSources()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disable(rssSource: RssSource) {
|
fun checkSelectedInterval(selectedIds: Set<String>, allItems: List<RssSourceItemUi>) {
|
||||||
execute {
|
if (selectedIds.isEmpty()) return
|
||||||
rssSource.enabled = false
|
val indices = allItems.mapIndexedNotNull { index, item ->
|
||||||
appDb.rssSourceDao.update(rssSource)
|
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
|
package io.legado.app.ui.rss.subscription
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import androidx.compose.runtime.Composable
|
||||||
import android.view.MenuItem
|
import io.legado.app.base.BaseComposeActivity
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 规则订阅界面
|
* 规则订阅界面
|
||||||
*/
|
*/
|
||||||
class RuleSubActivity : BaseActivity<ActivityRuleSubBinding>(),
|
class RuleSubActivity : BaseComposeActivity() {
|
||||||
RuleSubAdapter.Callback {
|
|
||||||
|
|
||||||
override val binding by viewBinding(ActivityRuleSubBinding::inflate)
|
|
||||||
private val adapter by lazy { RuleSubAdapter(this, this) }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
initView()
|
|
||||||
initData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
|
@Composable
|
||||||
menuInflater.inflate(R.menu.source_subscription, menu)
|
override fun Content() {
|
||||||
return super.onCompatCreateOptionsMenu(menu)
|
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