Просмотр исходного кода

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

HapeLee 8 месяцев назад
Родитель
Сommit
12a46e8cae
21 измененных файлов с 2151 добавлено и 1553 удалено
  1. 31 0
      app/src/main/java/io/legado/app/data/repository/RssRepository.kt
  2. 0 86
      app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt
  3. 37 162
      app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt
  4. 245 0
      app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt
  5. 14 0
      app/src/main/java/io/legado/app/ui/main/rss/RssUiState.kt
  6. 69 3
      app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt
  7. 0 113
      app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt
  8. 8 163
      app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesActivity.kt
  9. 226 0
      app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt
  10. 129 1
      app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesViewModel.kt
  11. 0 146
      app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt
  12. 16 412
      app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt
  13. 0 254
      app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt
  14. 403 0
      app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt
  15. 268 81
      app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt
  16. 9 132
      app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubActivity.kt
  17. 266 0
      app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt
  18. 136 0
      app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubViewModel.kt
  19. 146 0
      app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt
  20. 64 0
      app/src/main/java/io/legado/app/ui/widget/components/SourceIcon.kt
  21. 84 0
      app/src/main/java/io/legado/app/ui/widget/components/dialog/TextListInputDialog.kt

+ 31 - 0
app/src/main/java/io/legado/app/data/repository/RssRepository.kt

@@ -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
+}

+ 0 - 86
app/src/main/java/io/legado/app/ui/main/rss/RssAdapter.kt

@@ -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)
-    }
-}

+ 37 - 162
app/src/main/java/io/legado/app/ui/main/rss/RssFragment.kt

@@ -1,26 +1,16 @@
 package io.legado.app.ui.main.rss
 
-import android.os.Build
 import android.os.Bundle
-import android.transition.TransitionManager
-import android.view.Menu
-import android.view.MenuItem
-import android.view.SubMenu
+import android.view.LayoutInflater
 import android.view.View
-import androidx.appcompat.widget.SearchView
-import androidx.core.view.isVisible
+import android.view.ViewGroup
+import androidx.compose.ui.platform.ComposeView
 import androidx.fragment.app.viewModels
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
 import io.legado.app.R
 import io.legado.app.base.VMBaseFragment
-import io.legado.app.constant.AppLog
-import io.legado.app.data.AppDatabase
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssSource
-import io.legado.app.databinding.FragmentRssBinding
-import io.legado.app.databinding.ItemRssBinding
 import io.legado.app.lib.dialogs.alert
+import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.main.MainFragmentInterface
 import io.legado.app.ui.rss.article.RssSortActivity
 import io.legado.app.ui.rss.favorites.RssFavoritesActivity
@@ -28,25 +18,15 @@ import io.legado.app.ui.rss.read.ReadRssActivity
 import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
 import io.legado.app.ui.rss.source.manage.RssSourceActivity
 import io.legado.app.ui.rss.subscription.RuleSubActivity
-import io.legado.app.utils.flowWithLifecycleAndDatabaseChange
+import io.legado.app.ui.theme.AppTheme
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.startActivity
-import io.legado.app.utils.transaction
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.launch
-
 
 /**
  * 订阅界面
  */
 class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
-    MainFragmentInterface,
-    RssAdapter.CallBack {
+    MainFragmentInterface {
 
     constructor(position: Int) : this() {
         val bundle = Bundle()
@@ -56,140 +36,36 @@ class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
 
     override val position: Int? get() = arguments?.getInt("position")
 
-    private val binding by viewBinding(FragmentRssBinding::bind)
     override val viewModel by viewModels<RssViewModel>()
-    private val adapter by lazy {
-        RssAdapter(requireContext(), this, this, viewLifecycleOwner.lifecycle)
-    }
-    private val searchView: SearchView by lazy { binding.searchBar }
-    private var groupsFlowJob: Job? = null
-    private var rssFlowJob: Job? = null
-    private val groups = linkedSetOf<String>()
-    private var groupsMenu: SubMenu? = null
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-        setSupportToolbar(binding.topBar)
-        initSearchView()
-        initRecyclerView()
-        initGroupData()
-        upRssFlowJob()
-        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S)
-            binding.appBar.fitsSystemWindows = true
-    }
 
-    override fun onCompatCreateOptionsMenu(menu: Menu) {
-        menuInflater.inflate(R.menu.main_rss, menu)
-        groupsMenu = menu.findItem(R.id.menu_group)?.subMenu
-        upGroupsMenu()
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem) {
-        super.onCompatOptionsItemSelected(item)
-        when (item.itemId) {
-            R.id.menu_rss_config -> startActivity<RssSourceActivity>()
-            R.id.menu_rss_star -> startActivity<RssFavoritesActivity>()
-            R.id.menu_rss_search -> {
-                TransitionManager.beginDelayedTransition(binding.rootView)
-                binding.searchBar.visibility =
-                    if (binding.searchBar.isVisible) View.GONE else View.VISIBLE
-            }
-            else -> if (item.groupId == R.id.menu_group_text) {
-                if (item.title == getString(R.string.all)) {
-                    upRssFlowJob()
-                    searchView.setQuery("", false)
-                } else {
-                    searchView.setQuery(item.title, false)
-                    upRssFlowJob("group:${item.title}")
+    override fun 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) }
+                    )
                 }
             }
-
         }
     }
 
-    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 onFragmentCreated(view: View, savedInstanceState: Bundle?) {
+        // Compose handles UI
     }
 
-    override fun openRss(rssSource: RssSource) {
+    private fun openRss(rssSource: RssSource) {
         if (rssSource.singleUrl) {
             viewModel.getSingleUrl(rssSource) { url ->
                 if (url.startsWith("http", true)) {
@@ -208,17 +84,20 @@ class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
         }
     }
 
-    override fun toTop(rssSource: RssSource) {
-        viewModel.topSource(rssSource)
-    }
-
-    override fun edit(rssSource: RssSource) {
+    private fun edit(rssSource: RssSource) {
         startActivity<RssSourceEditActivity> {
             putExtra("sourceUrl", rssSource.sourceUrl)
         }
     }
 
-    override fun del(rssSource: RssSource) {
+    private fun login(rssSource: RssSource) {
+        startActivity<SourceLoginActivity> {
+            putExtra("type", "rssSource")
+            putExtra("key", rssSource.sourceUrl)
+        }
+    }
+
+    private fun del(rssSource: RssSource) {
         alert(R.string.draw) {
             setMessage(getString(R.string.sure_del) + "\n" + rssSource.sourceName)
             noButton()
@@ -227,8 +106,4 @@ class RssFragment() : VMBaseFragment<RssViewModel>(R.layout.fragment_rss),
             }
         }
     }
-
-    override fun disable(rssSource: RssSource) {
-        viewModel.disable(rssSource)
-    }
-}
+}

+ 245 - 0
app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt

@@ -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()
+        )
+    }
+}

+ 14 - 0
app/src/main/java/io/legado/app/ui/main/rss/RssUiState.kt

@@ -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>

+ 69 - 3
app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt

@@ -1,15 +1,83 @@
 package io.legado.app.ui.main.rss
 
 import android.app.Application
+import androidx.lifecycle.viewModelScope
 import com.script.rhino.runScriptWithContext
 import io.legado.app.base.BaseViewModel
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssSource
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
 
 class RssViewModel(application: Application) : BaseViewModel(application) {
 
+    private val _uiState = MutableStateFlow(RssUiState())
+    val uiState = _uiState.asStateFlow()
+
+    init {
+        initGroupData()
+        initRssData()
+    }
+
+    private fun initGroupData() {
+        viewModelScope.launch {
+            appDb.rssSourceDao.flowEnabledGroups()
+                .flowOn(IO)
+                .collect { groups ->
+                    _uiState.update { state -> state.copy(groups = groups) }
+                }
+        }
+    }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private fun initRssData() {
+        combine(
+            _uiState.map { it.searchKey }.distinctUntilChanged(),
+            _uiState.map { it.group }.distinctUntilChanged()
+        ) { searchKey, group ->
+            searchKey to group
+        }
+            .flatMapLatest { (searchKey, group) ->
+                when {
+                    searchKey.isNotEmpty() -> appDb.rssSourceDao.flowEnabled(searchKey)
+                    group.isNotEmpty() -> appDb.rssSourceDao.flowEnabledByGroup(group)
+                    else -> appDb.rssSourceDao.flowEnabled()
+                }
+            }
+            .flowOn(IO)
+            .onEach { sources ->
+                _uiState.update { state -> state.copy(items = sources) }
+            }
+            .launchIn(viewModelScope)
+    }
+
+    fun search(key: String) {
+        _uiState.update { it.copy(searchKey = key, isSearch = key.isNotEmpty()) }
+    }
+
+    fun setGroup(group: String) {
+        _uiState.update { it.copy(group = group, searchKey = "", isSearch = false) }
+    }
+
+    fun toggleSearchVisible(visible: Boolean) {
+        _uiState.update {
+            it.copy(isSearch = visible, searchKey = if (visible) it.searchKey else "")
+        }
+    }
+
     fun topSource(vararg sources: RssSource) {
         execute {
             sources.sortBy { it.customOrder }
@@ -78,6 +146,4 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
                 context.toastOnUi(it.localizedMessage)
             }
     }
-
-
-}
+}

+ 0 - 113
app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt

@@ -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)
-                                            )
-                                        }
-                                    }
-                                }
-                            )
-                        }
-                    }
-                }
-            }
-        }
-    }
-}

+ 8 - 163
app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesActivity.kt

@@ -1,177 +1,22 @@
-@file:Suppress("DEPRECATION")
-
 package io.legado.app.ui.rss.favorites
 
 import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.SubMenu
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentStatePagerAdapter
-import androidx.lifecycle.lifecycleScope
-import androidx.viewpager.widget.ViewPager
-import io.legado.app.R
-import io.legado.app.base.BaseActivity
-import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
-import io.legado.app.databinding.ActivityRssFavoritesBinding
-import io.legado.app.lib.dialogs.alert
-//import io.legado.app.lib.theme.accentColor
-import io.legado.app.utils.gone
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.launch
+import androidx.compose.runtime.Composable
+import io.legado.app.base.BaseComposeActivity
 
 /**
  * 收藏夹
  */
-class RssFavoritesActivity : BaseActivity<ActivityRssFavoritesBinding>() {
-
-    override val binding by viewBinding(ActivityRssFavoritesBinding::inflate)
-    private val adapter by lazy { TabFragmentPageAdapter() }
-    private var groupList = mutableListOf<String>()
-    private var groupsMenu: SubMenu? = null
-    private var currentGroup = ""
+class RssFavoritesActivity : BaseComposeActivity() {
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
-        initView()
-        upFragments()
-    }
-
-    override fun onResume() {
-        super.onResume()
-        //从ReadRssActivity退出时,判断是否需要重新定位tabLayout选中项
-        if (currentGroup.isNotEmpty() && groupList.isNotEmpty()){
-            var item = groupList.indexOf(currentGroup)
-            val currentItem = binding.viewPager.currentItem
-            //如果坐标没有变化,则结束
-            if(item == currentItem){
-                return
-            }
-            if (item == -1){
-                item = currentItem
-            }
-            lifecycleScope.launch {
-                delay(100)
-                binding.tabLayout.getTabAt(item)?.select()
-            }
-        }
-    }
-
-    private fun initView() {
-        binding.viewPager.adapter = adapter
-        binding.viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
-            override fun onPageScrolled(
-                position: Int,
-                positionOffset: Float,
-                positionOffsetPixels: Int
-            ) {
-            }
-
-            override fun onPageSelected(position: Int) {
-                currentGroup = groupList[position]
-            }
-
-            override fun onPageScrollStateChanged(state: Int) {}
-
-        })
-        binding.tabLayout.setupWithViewPager(binding.viewPager)
-        //binding.tabLayout.setSelectedTabIndicatorColor(accentColor)
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.rss_favorites, menu)
-        groupsMenu = menu.findItem(R.id.menu_group)?.subMenu
-        upGroupsMenu()
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    private fun upGroupsMenu() = groupsMenu?.let { subMenu ->
-        subMenu.removeGroup(R.id.menu_group)
-        groupList.forEachIndexed { index, it ->
-            subMenu.add(R.id.menu_group, Menu.NONE, index, it)
-        }
     }
 
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        if (item.groupId == R.id.menu_group) {
-            binding.viewPager.setCurrentItem(item.order)
-        } else {
-            when (item.itemId) {
-                R.id.menu_del_group -> deleteGroup()
-                R.id.menu_del_all -> deleteAll()
-            }
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun upFragments() {
-        lifecycleScope.launch {
-            appDb.rssStarDao.flowGroups().catch {
-                AppLog.put("订阅分组数据获取失败\n${it.localizedMessage}", it)
-            }.distinctUntilChanged().flowOn(IO).collect {
-                groupList.clear()
-                groupList.addAll(it)
-                if (groupList.size == 1) {
-                    binding.tabLayout.gone()
-                } else {
-                    binding.tabLayout.visible()
-                }
-                if (groupsMenu != null) {
-                    upGroupsMenu()
-                }
-                adapter.notifyDataSetChanged()
-            }
-        }
-    }
-
-    private fun deleteGroup() {
-        alert(R.string.draw) {
-            val item = binding.viewPager.currentItem
-            val group = groupList[item]
-            setMessage(getString(R.string.sure_del) + "\n<" + group + ">" + getString(R.string.group))
-            noButton()
-            yesButton {
-                appDb.rssStarDao.deleteByGroup(group)
-            }
-        }
-    }
-
-    private fun deleteAll() {
-        alert(R.string.draw) {
-            setMessage(getString(R.string.sure_del) + "\n<" + getString(R.string.all) + ">" + getString(R.string.favorite))
-            noButton()
-            yesButton {
-                appDb.rssStarDao.deleteAll()
-            }
-        }
-    }
-
-    private inner class TabFragmentPageAdapter :
-        FragmentStatePagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
-
-        override fun getItemPosition(`object`: Any): Int {
-            return POSITION_NONE
-        }
-
-        override fun getPageTitle(position: Int): CharSequence {
-            return groupList[position]
-        }
-
-        override fun getItem(position: Int): Fragment {
-            val group = groupList[position]
-            return RssFavoritesFragment(group)
-        }
-
-        override fun getCount(): Int {
-            return groupList.size
-        }
-
+    @Composable
+    override fun Content() {
+        RssFavoritesScreen(
+            onBackClick = { finish() }
+        )
     }
 }

+ 226 - 0
app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt

@@ -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()
+                                }
+                            )
+                        }
+                    )
+                }
+            }
+        }
+    }
+}

+ 129 - 1
app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesViewModel.kt

@@ -1,9 +1,137 @@
 package io.legado.app.ui.rss.favorites
 
 import android.app.Application
+import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
-
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.RssStar
+import io.legado.app.ui.widget.components.rules.ListUiState
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
 
 class RssFavoritesViewModel(application: Application) : BaseViewModel(application) {
 
+    private val _searchKey = MutableStateFlow("")
+    private val _isSearch = MutableStateFlow(false)
+    private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
+    private val _currentGroup = MutableStateFlow("")
+
+    val groups = appDb.rssStarDao.flowGroups()
+        .flowOn(IO)
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    val state: StateFlow<RssFavoritesUiState> = combine(
+        _currentGroup,
+        _searchKey,
+        _isSearch,
+        _selectedIds,
+        _currentGroup.flatMapLatest { group ->
+            if (group.isEmpty()) {
+                // If group is empty, we might want to wait for the first group from 'groups' flow
+                // Or just show nothing/default. The Activity/Screen should set the first group.
+                appDb.rssStarDao.liveAll()
+            } else {
+                appDb.rssStarDao.flowByGroup(group)
+            }
+        }
+    ) { group, searchKey, isSearch, selectedIds, items ->
+        val filteredItems = if (isSearch && searchKey.isNotBlank()) {
+            items.filter { it.title.contains(searchKey, ignoreCase = true) }
+        } else {
+            items
+        }
+        RssFavoritesUiState(
+            items = filteredItems,
+            selectedIds = selectedIds,
+            searchKey = searchKey,
+            isSearch = isSearch,
+            currentGroup = group
+        )
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RssFavoritesUiState())
+
+    fun onSearchToggle(isSearch: Boolean) {
+        _isSearch.value = isSearch
+        if (!isSearch) _searchKey.value = ""
+    }
+
+    fun onSearchQueryChange(query: String) {
+        _searchKey.value = query
+    }
+
+    fun onGroupChange(group: String) {
+        _currentGroup.value = group
+        _selectedIds.value = emptySet()
+    }
+
+    fun toggleSelection(rssStar: RssStar) {
+        val id = "${rssStar.origin}|${rssStar.link}"
+        _selectedIds.update {
+            if (it.contains(id)) it - id else it + id
+        }
+    }
+
+    fun selectAll() {
+        _selectedIds.value = state.value.items.map { "${it.origin}|${it.link}" }.toSet()
+    }
+
+    fun selectInvert() {
+        val allIds = state.value.items.map { "${it.origin}|${it.link}" }.toSet()
+        _selectedIds.update { current ->
+            allIds - current
+        }
+    }
+
+    fun clearSelection() {
+        _selectedIds.value = emptySet()
+    }
+
+    fun deleteSelected() {
+        val selected = _selectedIds.value
+        viewModelScope.launch(IO) {
+            state.value.items.forEach {
+                val id = "${it.origin}|${it.link}"
+                if (selected.contains(id)) {
+                    appDb.rssStarDao.delete(it.origin, it.link)
+                }
+            }
+            clearSelection()
+        }
+    }
+
+    fun deleteGroup(group: String) {
+        viewModelScope.launch(IO) {
+            appDb.rssStarDao.deleteByGroup(group)
+        }
+    }
+
+    fun deleteAll() {
+        viewModelScope.launch(IO) {
+            appDb.rssStarDao.deleteAll()
+        }
+    }
+
+    fun deleteStar(rssStar: RssStar) {
+        viewModelScope.launch(IO) {
+            appDb.rssStarDao.delete(rssStar.origin, rssStar.link)
+        }
+    }
+
+    data class RssFavoritesUiState(
+        override val items: List<RssStar> = emptyList(),
+        override val selectedIds: Set<String> = emptySet(),
+        override val searchKey: String = "",
+        override val isSearch: Boolean = false,
+        override val isLoading: Boolean = false,
+        val currentGroup: String = ""
+    ) : ListUiState<RssStar>
 }

+ 0 - 146
app/src/main/java/io/legado/app/ui/rss/source/manage/GroupManageDialog.kt

@@ -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)
-                    }
-                }
-            }
-        }
-    }
-
-}

+ 16 - 412
app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt

@@ -1,424 +1,28 @@
 package io.legado.app.ui.rss.source.manage
 
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.MotionEvent
-import android.view.SubMenu
-import androidx.activity.viewModels
-import androidx.appcompat.widget.PopupMenu
-import androidx.appcompat.widget.SearchView
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.ItemTouchHelper
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.RssSource
-import io.legado.app.databinding.ActivityRssSourceBinding
-import io.legado.app.databinding.DialogEditTextBinding
-import io.legado.app.help.DirectLinkUpload
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.ui.association.ImportRssSourceDialog
-import io.legado.app.ui.file.HandleFileContract
-import io.legado.app.ui.qrcode.QrCodeResult
+import androidx.compose.runtime.Composable
+import io.legado.app.base.BaseComposeActivity
 import io.legado.app.ui.rss.source.edit.RssSourceEditActivity
-import io.legado.app.ui.widget.SelectActionBar
-import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
-import io.legado.app.ui.widget.recycler.ItemTouchCallback
-import io.legado.app.ui.widget.recycler.VerticalDivider
-import io.legado.app.utils.ACache
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.hideSoftInput
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.launch
-import io.legado.app.utils.readText
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.share
-import io.legado.app.utils.shouldHideSoftInput
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
-import io.legado.app.utils.splitNotBlank
+import io.legado.app.ui.theme.AppTheme
 import io.legado.app.utils.startActivity
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.transaction
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.launch
 
-/**
- * 订阅源管理
- */
-class RssSourceActivity : VMBaseActivity<ActivityRssSourceBinding, RssSourceViewModel>(),
-    PopupMenu.OnMenuItemClickListener,
-    SelectActionBar.CallBack,
-    RssSourceAdapter.CallBack {
+class RssSourceActivity : BaseComposeActivity() {
 
-    override val binding by viewBinding(ActivityRssSourceBinding::inflate)
-    override val viewModel by viewModels<RssSourceViewModel>()
-    private val importRecordKey = "rssSourceRecordKey"
-    private val adapter by lazy { RssSourceAdapter(this, this) }
-    private val searchView: SearchView by lazy {
-        binding.titleBar.findViewById(R.id.search_view)
-    }
-    private var sourceFlowJob: Job? = null
-    private var groups = arrayListOf<String>()
-    private var groupMenu: SubMenu? = null
-    private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
-        it ?: return@registerForActivityResult
-        showDialogFragment(
-            ImportRssSourceDialog(it)
-        )
-    }
-    private val importDoc = registerForActivityResult(HandleFileContract()) {
-        kotlin.runCatching {
-            it.uri?.readText(this)?.let {
-                showDialogFragment(
-                    ImportRssSourceDialog(it)
-                )
-            }
-        }.onFailure {
-            toastOnUi("readTextError:${it.localizedMessage}")
-        }
-    }
-    private val exportResult = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            alert(R.string.export_success) {
-                if (uri.toString().isAbsUrl()) {
-                    setMessage(DirectLinkUpload.getSummary())
-                }
-                val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                    editView.hint = getString(R.string.path)
-                    editView.setText(uri.toString())
-                }
-                customView { alertBinding.root }
-                okButton {
-                    sendToClip(uri.toString())
-                }
-            }
-        }
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        initRecyclerView()
-        initSearchView()
-        initGroupFlow()
-        upSourceFlow()
-        initSelectActionBar()
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.rss_source, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
-        groupMenu = menu.findItem(R.id.menu_group)?.subMenu
-        upGroupMenu()
-        return super.onPrepareOptionsMenu(menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_add -> startActivity<RssSourceEditActivity>()
-            R.id.menu_import_local -> importDoc.launch {
-                mode = HandleFileContract.FILE
-                allowExtensions = arrayOf("txt", "json")
-            }
-
-            R.id.menu_import_onLine -> showImportDialog()
-            R.id.menu_import_qr -> qrCodeResult.launch()
-            R.id.menu_group_manage -> showDialogFragment<GroupManageDialog>()
-            R.id.menu_import_default -> viewModel.importDefault()
-            R.id.menu_enabled_group -> {
-                searchView.setQuery(getString(R.string.enabled), true)
-            }
-
-            R.id.menu_disabled_group -> {
-                searchView.setQuery(getString(R.string.disabled), true)
-            }
-
-            R.id.menu_group_login -> {
-                searchView.setQuery(getString(R.string.need_login), true)
-            }
-
-            R.id.menu_group_null -> {
-                searchView.setQuery(getString(R.string.no_group), true)
-            }
-
-            R.id.menu_help -> showHelp("SourceMRssHelp")
-            else -> if (item.groupId == R.id.source_group) {
-                searchView.setQuery("group:${item.title}", true)
-            }
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    override fun onMenuItemClick(item: MenuItem?): Boolean {
-        when (item?.itemId) {
-            R.id.menu_enable_selection -> viewModel.enableSelection(adapter.selection)
-            R.id.menu_disable_selection -> viewModel.disableSelection(adapter.selection)
-            R.id.menu_add_group -> selectionAddToGroups()
-            R.id.menu_remove_group -> selectionRemoveFromGroups()
-            R.id.menu_top_sel -> viewModel.topSource(*adapter.selection.toTypedArray())
-            R.id.menu_bottom_sel -> viewModel.bottomSource(*adapter.selection.toTypedArray())
-            R.id.menu_export_selection -> viewModel.saveToFile(adapter.selection) { file ->
-                exportResult.launch {
-                    mode = HandleFileContract.EXPORT
-                    fileData = HandleFileContract.FileData(
-                        "exportRssSource.json", file, "application/json"
-                    )
-                }
-            }
-
-            R.id.menu_share_source -> viewModel.saveToFile(adapter.selection) {
-                share(it)
-            }
-
-            R.id.menu_check_selected_interval -> adapter.checkSelectedInterval()
-        }
-        return true
-    }
-
-    private fun initRecyclerView() {
-        //binding.recyclerView.setEdgeEffectColor(primaryColor)
-        binding.recyclerView.addItemDecoration(VerticalDivider(this))
-        binding.recyclerView.adapter = adapter
-        // When this page is opened, it is in selection mode
-        val dragSelectTouchHelper: DragSelectTouchHelper =
-            DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
-        dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
-        dragSelectTouchHelper.activeSlideSelect()
-        // Note: need judge selection first, so add ItemTouchHelper after it.
-        val itemTouchCallback = ItemTouchCallback(adapter)
-        itemTouchCallback.isCanDrag = true
-        ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
-    }
-
-    private fun initSearchView() {
-        binding.titleBar.findViewById<SearchView>(R.id.search_view).let {
-            //it.applyTint(primaryTextColor)
-            it.onActionViewExpanded()
-            it.queryHint = getString(R.string.search_rss_source)
-            it.clearFocus()
-            it.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
-                override fun onQueryTextSubmit(query: String?): Boolean {
-                    return false
-                }
-
-                override fun onQueryTextChange(newText: String?): Boolean {
-                    upSourceFlow(newText)
-                    return false
-                }
-            })
-        }
-    }
-
-    private fun initSelectActionBar() {
-        binding.selectActionBar.setMainActionText(R.string.delete)
-        binding.selectActionBar.inflateMenu(R.menu.rss_source_sel)
-        binding.selectActionBar.setOnMenuItemClickListener(this)
-        binding.selectActionBar.setCallBack(this)
-    }
-
-    private fun initGroupFlow() {
-        lifecycleScope.launch {
-            appDb.rssSourceDao.flowGroups().conflate().collect {
-                groups.clear()
-                groups.addAll(it)
-                upGroupMenu()
-            }
-        }
-    }
-
-    @SuppressLint("InflateParams")
-    private fun selectionAddToGroups() {
-        alert(titleResource = R.string.add_group) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.setHint(R.string.group_name)
-                editView.setFilterValues(groups.toList())
-                editView.dropDownHeight = 180.dpToPx()
-            }
-            customView { alertBinding.root }
-            okButton {
-                alertBinding.editView.text?.toString()?.let {
-                    if (it.isNotEmpty()) {
-                        viewModel.selectionAddToGroups(adapter.selection, it)
-                    }
-                }
-            }
-            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)
+    @Composable
+    override fun Content() {
+        AppTheme {
+            RssSourceScreen(
+                onBackClick = { finish() },
+                onEditSource = { source ->
+                    startActivity<RssSourceEditActivity> {
+                        putExtra("sourceUrl", source.sourceUrl)
                     }
+                },
+                onAddSource = {
+                    startActivity<RssSourceEditActivity>()
                 }
-            }
-            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()
-    }
-
 }

+ 0 - 254
app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceAdapter.kt

@@ -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()
-    }
-}

+ 403 - 0
app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt

@@ -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)
+                )
+            }
+        }
+    }
+}

+ 268 - 81
app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt

@@ -1,82 +1,280 @@
 package io.legado.app.ui.rss.source.manage
 
 import android.app.Application
-import android.text.TextUtils
-import io.legado.app.base.BaseViewModel
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.viewModelScope
+import io.legado.app.R
+import io.legado.app.base.BaseRuleViewModel
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssSource
+import io.legado.app.data.repository.UploadRepository
 import io.legado.app.help.DefaultData
 import io.legado.app.help.source.SourceHelp
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import io.legado.app.ui.widget.components.rules.InteractionState
+import io.legado.app.ui.widget.components.rules.ListUiState
+import io.legado.app.ui.widget.components.rules.SelectableItem
 import io.legado.app.utils.FileUtils
 import io.legado.app.utils.GSON
-import io.legado.app.utils.splitNotBlank
+import io.legado.app.utils.fromJsonArray
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.isJsonArray
+import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.stackTraceStr
 import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import java.io.File
 
-/**
- * 订阅源管理数据修改
- * 修改数据要copy,直接修改会导致界面不刷新
- */
-class RssSourceViewModel(application: Application) : BaseViewModel(application) {
+@Immutable
+data class RssSourceItemUi(
+    override val id: String,
+    val name: String,
+    val group: String?,
+    val isEnabled: Boolean,
+    val source: RssSource
+) : SelectableItem<String>
+
+data class RssSourceUiState(
+    override val items: List<RssSourceItemUi> = emptyList(),
+    override val selectedIds: Set<String> = emptySet(),
+    override val searchKey: String = "",
+    val groupFilterName: String? = null,
+    val interaction: InteractionState = InteractionState()
+) : ListUiState<RssSourceItemUi> {
+    override val isSearch: Boolean get() = interaction.isSearchMode
+    override val isLoading: Boolean get() = interaction.isUploading
+}
+
+class RssSourceViewModel(
+    application: Application,
+    uploadRepository: UploadRepository
+) : BaseRuleViewModel<RssSourceItemUi, RssSource, String, RssSourceUiState>(
+    application,
+    RssSourceUiState(interaction = InteractionState(isLoading = true)),
+    uploadRepository
+) {
+    companion object {
+        const val FILTER_ENABLED = "@enabled"
+        const val FILTER_DISABLED = "@disabled"
+        const val FILTER_LOGIN = "@login"
+        const val FILTER_NO_GROUP = "@noGroup"
+        const val PREFIX_GROUP = "group:"
+    }
+
+    private val dao = appDb.rssSourceDao
+
+    override val rawDataFlow: Flow<List<RssSource>> = dao.flowAll()
+
+    val groupsFlow: StateFlow<List<String>> = dao.flowGroups()
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+
+    private val _groupFilterName = MutableStateFlow<String?>(null)
+    val groupFilterName = _groupFilterName.asStateFlow()
+
+    override val uiState: StateFlow<RssSourceUiState> by lazy {
+        combine(
+            super.uiState,
+            _groupFilterName
+        ) { baseState, filterName ->
+            baseState.copy(groupFilterName = filterName)
+        }.stateIn(
+            scope = viewModelScope,
+            started = SharingStarted.WhileSubscribed(5000),
+            initialValue = initialState
+        )
+    }
+
+    override fun setGroupFilter(filter: String?) {
+        super.setGroupFilter(filter)
+        _groupFilterName.value = when {
+            filter == null -> null
+            filter == FILTER_ENABLED -> context.getString(R.string.enabled)
+            filter == FILTER_DISABLED -> context.getString(R.string.disabled)
+            filter == FILTER_LOGIN -> context.getString(R.string.need_login)
+            filter == FILTER_NO_GROUP -> context.getString(R.string.no_group)
+            filter.startsWith(PREFIX_GROUP) -> filter.substringAfter(PREFIX_GROUP)
+            else -> filter
+        }
+    }
+
+    override fun filterData(
+        data: List<RssSource>,
+        searchKey: String,
+        groupFilter: String
+    ): List<RssSource> {
+        var filtered = data
+
+        if (groupFilter.isNotEmpty()) {
+            filtered = when {
+                groupFilter == FILTER_ENABLED -> filtered.filter { it.enabled }
+                groupFilter == FILTER_DISABLED -> filtered.filter { !it.enabled }
+                groupFilter == FILTER_LOGIN -> filtered.filter { !it.loginUrl.isNullOrEmpty() }
+                groupFilter == FILTER_NO_GROUP -> filtered.filter {
+                    it.sourceGroup.isNullOrEmpty() || it.sourceGroup?.contains(
+                        "未分组"
+                    ) == true
+                }
+
+                groupFilter.startsWith(PREFIX_GROUP) -> {
+                    val groupName = groupFilter.substringAfter(PREFIX_GROUP)
+                    filtered.filter { it.sourceGroup?.split(",")?.contains(groupName) == true }
+                }
+
+                else -> filtered
+            }
+        }
+
+        if (searchKey.isNotEmpty()) {
+            filtered = filtered.filter {
+                it.sourceName.contains(searchKey, ignoreCase = true) ||
+                        it.sourceUrl.contains(searchKey, ignoreCase = true) ||
+                        it.sourceGroup?.contains(searchKey, ignoreCase = true) == true ||
+                        it.sourceComment?.contains(searchKey, ignoreCase = true) == true
+            }
+        }
+
+        return filtered.sortedBy { it.customOrder }
+    }
+
+    override fun composeUiState(
+        items: List<RssSourceItemUi>,
+        selectedIds: Set<String>,
+        isSearch: Boolean,
+        isUploading: Boolean,
+        importState: BaseImportUiState<RssSource>
+    ): RssSourceUiState {
+        return RssSourceUiState(
+            items = items,
+            selectedIds = selectedIds,
+            searchKey = _searchKey.value,
+            interaction = InteractionState(
+                isSearchMode = isSearch,
+                isUploading = isUploading || (importState is BaseImportUiState.Loading),
+                isLoading = false
+            )
+        )
+    }
+
+    override fun RssSource.toUiItem() =
+        RssSourceItemUi(sourceUrl, sourceName, sourceGroup, enabled, this)
+
+    override fun ruleItemToEntity(item: RssSourceItemUi): RssSource = item.source
+
+    override suspend fun generateJson(entities: List<RssSource>): String = GSON.toJson(entities)
+
+    override fun parseImportRules(text: String): List<RssSource> {
+        return when {
+            text.isJsonArray() -> GSON.fromJsonArray<RssSource>(text).getOrThrow()
+            text.isJsonObject() -> listOf(GSON.fromJsonObject<RssSource>(text).getOrThrow())
+            else -> throw Exception("格式不正确")
+        }
+    }
+
+    override fun hasChanged(newRule: RssSource, oldRule: RssSource): Boolean {
+        return !newRule.equal(oldRule)
+    }
+
+    override suspend fun findOldRule(newRule: RssSource): RssSource? {
+        return withContext(Dispatchers.IO) { dao.getByKey(newRule.sourceUrl) }
+    }
+
+    override fun saveImportedRules() {
+        val state = _importState.value as? BaseImportUiState.Success<RssSource> ?: return
+        viewModelScope.launch(Dispatchers.IO) {
+            val rulesToSave = state.items
+                .filter { it.isSelected }
+                .map { it.data }
+            dao.insert(*rulesToSave.toTypedArray())
+            withContext(Dispatchers.Main) {
+                _importState.value = BaseImportUiState.Idle
+            }
+        }
+    }
+
+    fun saveSortOrder() {
+        val currentLocal = _localItems.value ?: return
+        viewModelScope.launch(Dispatchers.IO) {
+            val sources = currentLocal.mapIndexed { index, item ->
+                item.source.copy(customOrder = index + 1)
+            }
+            dao.update(*sources.toTypedArray())
+            withContext(Dispatchers.Main) {
+                _localItems.value = null
+            }
+        }
+    }
 
     fun topSource(vararg sources: RssSource) {
-        execute {
-            sources.sortBy { it.customOrder }
-            val minOrder = appDb.rssSourceDao.minOrder - 1
-            val array = Array(sources.size) {
-                sources[it].copy(customOrder = minOrder - it)
+        viewModelScope.launch(Dispatchers.IO) {
+            val minOrder = dao.minOrder - 1
+            val updated = sources.sortedBy { it.customOrder }.mapIndexed { index, source ->
+                source.copy(customOrder = minOrder - index)
             }
-            appDb.rssSourceDao.update(*array)
+            dao.update(*updated.toTypedArray())
         }
     }
 
     fun bottomSource(vararg sources: RssSource) {
-        execute {
-            sources.sortBy { it.customOrder }
-            val maxOrder = appDb.rssSourceDao.maxOrder + 1
-            val array = Array(sources.size) {
-                sources[it].copy(customOrder = maxOrder + it)
+        viewModelScope.launch(Dispatchers.IO) {
+            val maxOrder = dao.maxOrder + 1
+            val updated = sources.sortedBy { it.customOrder }.mapIndexed { index, source ->
+                source.copy(customOrder = maxOrder + index)
             }
-            appDb.rssSourceDao.update(*array)
+            dao.update(*updated.toTypedArray())
         }
     }
 
     fun del(vararg rssSource: RssSource) {
-        execute {
+        viewModelScope.launch(Dispatchers.IO) {
             SourceHelp.deleteRssSources(rssSource.toList())
         }
     }
 
+    fun delSelectionByIds(ids: Set<String>) {
+        viewModelScope.launch(Dispatchers.IO) {
+            val sources = dao.getRssSources(*ids.toTypedArray())
+            SourceHelp.deleteRssSources(sources)
+            _selectedIds.update { it - ids }
+        }
+    }
+
     fun update(vararg rssSource: RssSource) {
-        execute { appDb.rssSourceDao.update(*rssSource) }
+        viewModelScope.launch(Dispatchers.IO) { dao.update(*rssSource) }
     }
 
     fun upOrder() {
-        execute {
-            val sources = appDb.rssSourceDao.all
+        viewModelScope.launch(Dispatchers.IO) {
+            val sources = dao.all
             for ((index: Int, source: RssSource) in sources.withIndex()) {
                 source.customOrder = index + 1
             }
-            appDb.rssSourceDao.update(*sources.toTypedArray())
+            dao.update(*sources.toTypedArray())
         }
     }
 
-    fun enableSelection(sources: List<RssSource>) {
-        execute {
-            val array = Array(sources.size) {
-                sources[it].copy(enabled = true)
-            }
-            appDb.rssSourceDao.update(*array)
+    fun enableSelectionByIds(ids: Set<String>) {
+        viewModelScope.launch(Dispatchers.IO) {
+            val sources = dao.getRssSources(*ids.toTypedArray())
+            val updated = sources.map { it.copy(enabled = true) }
+            dao.update(*updated.toTypedArray())
         }
     }
 
-    fun disableSelection(sources: List<RssSource>) {
-        execute {
-            val array = Array(sources.size) {
-                sources[it].copy(enabled = false)
-            }
-            appDb.rssSourceDao.update(*array)
+    fun disableSelectionByIds(ids: Set<String>) {
+        viewModelScope.launch(Dispatchers.IO) {
+            val sources = dao.getRssSources(*ids.toTypedArray())
+            val updated = sources.map { it.copy(enabled = false) }
+            dao.update(*updated.toTypedArray())
         }
     }
 
@@ -94,75 +292,64 @@ class RssSourceViewModel(application: Application) : BaseViewModel(application)
         }
     }
 
-    fun selectionAddToGroups(sources: List<RssSource>, groups: String) {
-        execute {
-            val array = Array(sources.size) {
-                sources[it].copy().addGroup(groups)
-            }
-            appDb.rssSourceDao.update(*array)
-        }
-    }
-
-    fun selectionRemoveFromGroups(sources: List<RssSource>, groups: String) {
-        execute {
-            val array = Array(sources.size) {
-                sources[it].copy().removeGroup(groups)
-            }
-            appDb.rssSourceDao.update(*array)
+    fun selectionAddToGroups(ids: Set<String>, groups: String) {
+        viewModelScope.launch(Dispatchers.IO) {
+            val sources = dao.getRssSources(*ids.toTypedArray())
+            val updated = sources.map { it.copy().addGroup(groups) }
+            dao.update(*updated.toTypedArray())
         }
     }
 
-    fun addGroup(group: String) {
-        execute {
-            val sources = appDb.rssSourceDao.noGroup
-            sources.forEach { source ->
-                source.sourceGroup = group
-            }
-            appDb.rssSourceDao.update(*sources.toTypedArray())
+    fun selectionRemoveFromGroups(ids: Set<String>, groups: String) {
+        viewModelScope.launch(Dispatchers.IO) {
+            val sources = dao.getRssSources(*ids.toTypedArray())
+            val updated = sources.map { it.copy().removeGroup(groups) }
+            dao.update(*updated.toTypedArray())
         }
     }
 
-    fun upGroup(oldGroup: String, newGroup: String?) {
-        execute {
-            val sources = appDb.rssSourceDao.getByGroup(oldGroup)
+    fun upGroup(oldGroup: String, newGroup: String) {
+        viewModelScope.launch(Dispatchers.IO) {
+            val sources = dao.getByGroup(oldGroup)
             sources.forEach { source ->
-                source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let {
+                source.sourceGroup?.split(",")?.toHashSet()?.let {
                     it.remove(oldGroup)
-                    if (!newGroup.isNullOrEmpty())
-                        it.add(newGroup)
-                    source.sourceGroup = TextUtils.join(",", it)
+                    if (newGroup.isNotEmpty()) it.add(newGroup)
+                    source.sourceGroup = it.joinToString(",")
                 }
             }
-            appDb.rssSourceDao.update(*sources.toTypedArray())
+            dao.update(*sources.toTypedArray())
         }
     }
 
     fun delGroup(group: String) {
-        execute {
-            execute {
-                val sources = appDb.rssSourceDao.getByGroup(group)
-                sources.forEach { source ->
-                    source.sourceGroup?.splitNotBlank(",")?.toHashSet()?.let {
-                        it.remove(group)
-                        source.sourceGroup = TextUtils.join(",", it)
-                    }
+        viewModelScope.launch(Dispatchers.IO) {
+            val sources = dao.getByGroup(group)
+            sources.forEach { source ->
+                source.sourceGroup?.split(",")?.toHashSet()?.let {
+                    it.remove(group)
+                    source.sourceGroup = it.joinToString(",")
                 }
-                appDb.rssSourceDao.update(*sources.toTypedArray())
             }
+            dao.update(*sources.toTypedArray())
         }
     }
 
     fun importDefault() {
-        execute {
+        viewModelScope.launch(Dispatchers.IO) {
             DefaultData.importDefaultRssSources()
         }
     }
 
-    fun disable(rssSource: RssSource) {
-        execute {
-            rssSource.enabled = false
-            appDb.rssSourceDao.update(rssSource)
+    fun checkSelectedInterval(selectedIds: Set<String>, allItems: List<RssSourceItemUi>) {
+        if (selectedIds.isEmpty()) return
+        val indices = allItems.mapIndexedNotNull { index, item ->
+            if (selectedIds.contains(item.id)) index else null
         }
+        val min = indices.minOrNull() ?: return
+        val max = indices.maxOrNull() ?: return
+        val newSelection = allItems.subList(min, max + 1).map { it.id }.toSet()
+        _selectedIds.value = newSelection
     }
 
 }

+ 9 - 132
app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubActivity.kt

@@ -1,146 +1,23 @@
 package io.legado.app.ui.rss.subscription
 
 import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import androidx.core.view.isGone
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.ItemTouchHelper
-import io.legado.app.R
-import io.legado.app.base.BaseActivity
-import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.RuleSub
-import io.legado.app.databinding.ActivityRuleSubBinding
-import io.legado.app.databinding.DialogRuleSubEditBinding
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.ui.association.ImportBookSourceDialog
-import io.legado.app.ui.association.ImportReplaceRuleDialog
-import io.legado.app.ui.association.ImportRssSourceDialog
-import io.legado.app.ui.widget.recycler.ItemTouchCallback
-import io.legado.app.utils.applyNavigationBarPadding
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import androidx.compose.runtime.Composable
+import io.legado.app.base.BaseComposeActivity
 
 /**
  * 规则订阅界面
  */
-class RuleSubActivity : BaseActivity<ActivityRuleSubBinding>(),
-    RuleSubAdapter.Callback {
-
-    override val binding by viewBinding(ActivityRuleSubBinding::inflate)
-    private val adapter by lazy { RuleSubAdapter(this, this) }
+class RuleSubActivity : BaseComposeActivity() {
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
-        initView()
-        initData()
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.source_subscription, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    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())
-        }
+    @Composable
+    override fun Content() {
+        RuleSubScreen(
+            onBackClick = { finish() }
+        )
     }
 
-}
+}

+ 266 - 0
app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt

@@ -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))
+            }
+        }
+    )
+}

+ 136 - 0
app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubViewModel.kt

@@ -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>
+}

+ 146 - 0
app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt

@@ -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
+                    }
+                )
+            }
+        }
+    )
+}

+ 64 - 0
app/src/main/java/io/legado/app/ui/widget/components/SourceIcon.kt

@@ -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()
+            )
+        }
+    }
+}

+ 84 - 0
app/src/main/java/io/legado/app/ui/widget/components/dialog/TextListInputDialog.kt

@@ -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))
+            }
+        }
+    )
+}